Skip to content

release: develop → main (v0.8.0 — admin broadcasts + launch-celebration popup + drawer polish)#557

Merged
chronoai-shining merged 19 commits into
mainfrom
develop
May 14, 2026
Merged

release: develop → main (v0.8.0 — admin broadcasts + launch-celebration popup + drawer polish)#557
chronoai-shining merged 19 commits into
mainfrom
develop

Conversation

@chronoai-shining

Copy link
Copy Markdown
Collaborator

Minor release. After merge, the changeset-release workflow will:

  1. Open release/v0.8.0 PR consuming the 17 pending changesets (2 minor + 15 patch) and bumping both packages 0.7.2 → 0.8.0 in fixed mode.
  2. On bump-PR merge: tag v0.8.0, GitHub Release sourced from .github/release-notes-20260516.md, sync PR to develop (merge with Create a merge commit, never squash — per CLAUDE.md).

What ships

New Feature

  • Admin broadcast notifications — bilingual EN+ZH markdown, broadcast-all or target by email; per-user read receipts merged into the existing /notifications feed.
  • Click any notification to open a full bilingual markdown viewer modal (works for broadcasts and for body-but-no-link rows like quota credits).
  • Launch-day celebration popup on landing and pinned to top of News, walking new users through the free-credit promo and NyxID SSO redemption flow.

Changed

  • Playground and skill-generation drawers unified — flatter layout, pinned actions, restrained palette, package drawer absorbs the redundant Skill tab.
  • Playground, skill builder, quota chip, and model picker now fully translate to Chinese.
  • Redemption codes in audit notes mask with **** instead of ellipsis.

Fixed

  • Click-to-popup broadcast notification no longer crashes with React [Bug] Review queue page missing top-left back nav #185.
  • Quota credit notifications open in a readable modal instead of silently marking read.
  • Right-edge rail tabs use icon handles with horizontal tooltips, fixing upside-down CJK labels.

Issues closed

Test plan

  • Bot opens release/v0.8.0 PR — squash-merge it.
  • Tag + Release publish; sync PR opens; merge it with a merge commit (NOT squash) per CLAUDE.md.
  • Verify GitHub Release body uses .github/release-notes-20260516.md content (not the short fallback).

chore: sync main → develop after v0.7.2
* feat(web): broadcastsApi client + useBroadcasts hooks (#500)

Add the client + TanStack Query hooks that the upcoming admin
broadcast surface will sit on top of. Broadcasts are admin-authored
bilingual messages that fan out to every user's notification inbox;
this commit lays the typed read/write path against
/api/v1/admin/broadcasts and the cache-invalidation glue that keeps
the user-facing notification bell in sync when a broadcast is
created, edited, or deleted.

Mirrors the announcements client/hook shape (apiGet/Post/Patch/Delete
wrappers, useMutation -> invalidate query keys on success) so the
admin page in the next commit can reuse the same mental model.
Because broadcasts are merged into the existing /notifications feed
server-side, mutations here invalidate the notifications + unread
count keys too, so a fresh broadcast appears in every bell on the
next refetch cycle without a manual reload.

Both EN and ZH titles + bodies are required at create time (no
auto-fallback) — modeled as a `BilingualText` pair so the drawer
component can validate against the same shape.

* feat(api): broadcasts domain — types + Zod schemas (#500)

New `domains/broadcasts/` module. Defines the persistence shapes
(`BroadcastDocument`, `BroadcastReadReceiptDocument`) and the wire
shapes the admin surface returns (`AdminBroadcastResponse`,
`UserBroadcastFeedItem`).

Bilingual fields use **nested objects** (`titleI18n: { en, zh }`,
`bodyMarkdownI18n: { en, zh }`) instead of the flattened columns
announcements (#493) use. Two reasons:

  1. Both locales are required at create time — no fallback semantics
     to encode in flat field names like "EN canonical, ZH optional".
  2. Each broadcast carries exactly one title pair + one body pair, so
     the nested shape keeps the field count low and lets a PATCH
     unambiguously target a single locale.

Zod schemas in a separate `schemas.ts` file (kept off `types.ts` so
the OpenAPI builder can import schemas without dragging the whole
domain). Create requires both locales non-empty. Patch allows each
locale to be omitted, but a provided value must be non-empty —
admins cannot blank a locale via the wire, only by deleting the
broadcast. An empty i18n object (`titleI18n: {}`) is rejected so the
route never persists a no-op locale patch.

No bootstrap wiring yet — commits 2-5 build out the repo, service,
routes, and notifications merge on top.

* feat(web): BroadcastEditDrawer with bilingual markdown editor (#500)

Right-edge slide-in drawer that creates or edits a single broadcast,
mirroring AnnouncementEditDrawer's pattern (portal, Framer Motion
spring entry, ESC + backdrop dismiss, card-impression surface). The
shape is intentionally simpler than announcements: broadcasts have
no schedule window, no CTA, no enabled toggle — just a bilingual
title + bilingual markdown body.

Both EN and ZH are REQUIRED on every save. Broadcasts fan out to
every authenticated user's inbox, and unlike announcements they
don't auto-fall back across locales — the admin must explicitly
write what each audience reads. The validator enforces non-empty
trimmed strings on all four fields before the mutation fires; field
errors render under each input.

Reuses the shared MarkdownEditor (toolbar + sanitized live preview)
one instance per locale rather than building bespoke widgets, so the
authoring experience matches the existing announcement and skill
README editors.

Includes a Vitest happy-path test: fills all four fields, submits,
asserts the mutation was called with the trimmed bilingual payload
and the success toast fired. A second case proves the validator
blocks submission when any of the four required fields is empty.

* feat(api): broadcasts repository — CRUD + read receipts (#500)

Persistence layer for the broadcasts domain. Two Mongo collections:

  - `broadcasts`               — the broadcast docs themselves.
  - `broadcast_read_receipts`  — `(userId, broadcastId)` rows tracking
                                 which user has read which broadcast.

Receipts live in a separate collection rather than embedded on the
broadcast doc so write rate scales with (users × broadcasts) instead
of mutating a single hot doc on every read. A `(userId, broadcastId)`
unique index makes `markRead` idempotent — repeat writes use upsert
with `$setOnInsert` so the original `readAt` wins (first-read time
is the meaningful value, not last-read).

Repo surface:

  - listAll / getById / create / update / delete
  - markRead (idempotent)
  - markManyRead (batched, for `notifications.markAllRead`)
  - deleteAllForBroadcast (cascade hook owned by the service layer)
  - readCountForBroadcast / readCountsForBroadcasts (admin list)
  - unreadBroadcastIdsForUser (unread-count roll-up)
  - hasUserReadBroadcastsMap (per-id readAt lookup for the feed merge)

Cascade-delete is deliberately NOT folded into `delete()` — the
service layer owns the contract so the call site is explicit and
testable.

Patches on the bilingual `titleI18n` / `bodyMarkdownI18n` fields are
applied via read+merge rather than dot-path `$set`. The volume is
tiny (one extra read per admin edit) and it keeps the merge logic
readable without juggling 4 different `titleI18n.en` / `titleI18n.zh`
paths.

Colocated bun test covers the load-bearing behaviours: unique-index
enforcement at the DB level, markRead idempotence, cascade delete
isolation between broadcasts, and the bulk helpers used by the
notifications feed merge.

* feat(web): BroadcastsPage with list / edit / delete (#500)

Admin surface at /admin/broadcasts (mounted by the next commit).
Lists every broadcast newest-first with the columns the operator
needs day-to-day: locale-resolved title, an 80-char body teaser
(markdown stripped for readability), created / updated timestamps,
the per-broadcast read count rolled up from broadcast_read_receipts,
and edit / delete actions.

The page deliberately mirrors AnnouncementsPage's structure (header,
Card-wrapped table, drawer hand-off, ConfirmDialog for destructive
actions) so the admin's mental model carries across. Differences are
all things broadcasts simply don't have: no enabled toggle, no
schedule window column, no status badge — every row is live until
deleted. The clarity is the point.

Includes a tiny markdown-strip helper for the preview cell. It is
not a full parser — just enough to flatten fenced code, inline code,
links, images, headings, emphasis, blockquotes, and list markers
into a single line. The teaser is read-only and only ever shows ~80
characters, so a perfect parse is not load-bearing.

Re-exported through `pages/admin/index.ts` so App.tsx's lazy import
barrel keeps working without bespoke route plumbing.

* feat(api): broadcasts service — admin business logic (#500)

Thin service layer on top of the broadcasts repository. Three jobs:

  1. **Admin list with `readCount`.** Every row carries the number of
     distinct users that have read it — the admin page doubles as
     broadcast history. Counts come from a single grouped aggregate
     on `broadcast_read_receipts`, not N count calls.

  2. **Lifecycle logging.** Pino `info` on create / update / delete
     with broadcast id + actor id. `error` if the cascade delete pass
     after a successful broadcast removal fails — orphan receipts are
     harmless (no broadcast → never surface) but they pile up, so an
     operator should know.

  3. **Cascade on hard delete.** Order is broadcast-first,
     receipts-second: a user racing a `markRead` after the cascade
     pass can't insert a fresh orphan because the broadcast doc is
     already gone. The receipt cleanup error is swallowed (logged
     loudly) — the user-visible delete already succeeded, so failing
     the request would be worse than the orphan.

The freshly created broadcast trivially has `readCount = 0`, so the
`create()` path skips the count query — one less round-trip on the
hot admin write path.

Service-level test covers list enrichment, the merge in `update`,
404 surfaces for missing ids, and that `delete` actually calls
`deleteAllForBroadcast` on the underlying repo (not just nukes the
broadcast doc and leaves receipts dangling).

* feat(web): AdminLayout sidebar entry for broadcasts (#500)

Adds a /admin/broadcasts entry between Announcements and Settings in
the admin sidebar, plus the route registration in App.tsx using the
same lazy-import pattern as the other admin pages (single chunk via
the pages/admin barrel).

The new entry's label resolves through i18next (key
`nav.admin.broadcasts`, added in the i18n commit later in this
stack); the existing nav items keep their hardcoded labels to keep
this commit narrowly scoped to introducing the new surface rather
than refactoring the whole sidebar. The NavItem shape now supports
an `i18nLabel: true` flag so the next entry that wants i18n can
opt in the same way without breaking the existing rows.

Icon is the lucide-react `Bell` glyph inlined as a bare <svg> to
match the rest of the sidebar's icon style and avoid pulling in a
new dependency for a single row.

* feat(api): admin /broadcasts routes + bootstrap mount (#500)

Wire the broadcasts domain into the HTTP layer.

  GET    /api/v1/admin/broadcasts
  POST   /api/v1/admin/broadcasts
  PATCH  /api/v1/admin/broadcasts/:id
  DELETE /api/v1/admin/broadcasts/:id

Every route is admin-only — `nyxidAuthMiddleware()` followed by
`requirePermission("ornn:admin:skill")`, identical to the
announcements admin surface (#493). The public user-facing read path
is `/api/v1/notifications` after the notifications-feed merge lands
in the next commit.

Validation is Zod-driven via the schemas in commit 1. Parse failures
translate into the project-standard `INVALID_BROADCAST_INPUT`
AppError so the global error middleware formats them consistently.
404 surfaces are produced by the service (`BROADCAST_NOT_FOUND`),
not the route — keeps the contract uniform between PATCH/DELETE.

Bootstrap wiring sits next to announcements: build the repo, fire
`ensureIndexes()` fire-and-forget, build the service, mount the
routes under `/api/v1`. Same shape, no DI surprises.

Repository test fix: cast string `_id`s to `never` when bypassing
the repo and writing directly through the raw Mongo `Collection`
contract — matches the pattern used in `quota/repository.test.ts`.
The repo itself already does this internally on every read/write.

* feat(web): NotificationBell renders broadcast items (#500)

Backend now merges broadcasts into the existing /notifications feed
with a `source: "broadcast"` discriminator plus bilingual
`titleI18n` and `bodyMarkdownI18n` fields. This commit teaches the
shared `Notification` type and both renderers (NotificationBell +
NotificationsPage) about that new row shape so a freshly authored
broadcast lights up the bell and the badge for every user.

Notification type changes (extend, don't break):
  - `source: "user" | "broadcast"` discriminator, optional with a
    "user" default so legacy rows from before the merge still type-
    check.
  - `titleI18n` + `bodyMarkdownI18n` carry the bilingual content for
    broadcast items.
  - `title`, `userId`, `data` relaxed to optional because broadcasts
    have no per-user `userId` and resolve their title at render time.

Bell renders broadcast rows with:
  - A small "BROADCAST" mono tag in front of the title so the user
    can tell at a glance that this is an admin announcement rather
    than a per-user audit / quota notification.
  - The locale-resolved body rendered as sanitized markdown
    (react-markdown + remark-gfm + rehype-sanitize) — same safe
    pattern AnnouncementPopup uses for landing-page popups.
  - No navigation on click (broadcasts have no deep-link target);
    clicking just marks read and collapses the popover.

Unread badge already reflects the merged count because it sources
from `useUnreadNotificationCount` which calls the backend's
`/unread-count` endpoint — server-side does the union.

NotificationsPage gets the same locale-resolve + markdown-render
treatment so the full list view stays consistent with the bell.

* feat(web): i18n strings for admin broadcasts + bell tag (#500)

Adds the en + zh translations for everything the broadcast feature
touches:

  nav.admin.broadcasts           — sidebar label for /admin/broadcasts.
  notifications.broadcast.tag    — small "Broadcast" chip in the bell.
  adminPages.broadcasts.*        — full admin surface: page eyebrow /
    title / description, table headers, empty state, delete confirm,
    toasts (created / updated / deleted + their failure counterparts),
    drawer chrome (section headings, field labels, placeholders, save
    button), and the four required-field validation messages.

Drawer placeholders + section headings for the Chinese form fields
stay in Chinese on both locale files — they're guidance text the
operator sees while filling the ZH side of a bilingual record, not
content the end-user reads. The error messages are localized per
file so an EN-locale admin sees English errors and a ZH-locale admin
sees Chinese errors.

Description copy is intentional content, not boilerplate — it
clarifies what a broadcast is and how it differs from an
announcement (no schedule, hard delete, both languages required) so
an operator reading the page header alone understands the model.

* feat(api): merge broadcasts into /notifications feed + unread + mark-read (#500)

Extend the notifications service so admin-authored broadcasts (#500)
surface through the same `/api/v1/notifications` feed the bell
already renders. No new user-facing endpoints — broadcasts and
per-user notifications share the existing routes:

  GET  /api/v1/notifications              → merged feed
  GET  /api/v1/notifications/unread-count → per-user + unread broadcasts
  POST /api/v1/notifications/:id/read     → routes by id type
  POST /api/v1/notifications/mark-all-read → both sources

**Feed shape change (additive).** The wire format is now a
discriminated union — each row carries `source: "user"` (existing
title/body/category/data) or `source: "broadcast"` (bilingual
`titleI18n` / `bodyMarkdownI18n` markdown). The frontend resolves
the locale at render time, same pattern as announcements (#493).
Sort is `createdAt` desc across the union so a fresh broadcast
interleaves correctly between recent per-user notifications. The
per-page cap is applied AFTER the merge so a chatty admin can't
starve per-user notifications out of the feed.

**markRead routes by id type.** `POST /notifications/:id/read` was
single-id before this commit; it still is. The service looks up the
id in `notifications` first (the common case — audit / quota events
fire orders of magnitude more often than broadcasts), and only
checks `broadcasts` on miss. Unknown id still throws
NOTIFICATION_NOT_FOUND so existing 404 semantics are preserved.

A new `markManyRead(userId, ids[])` service method handles mixed
batches and silently skips unknown ids — "ensure these are marked
read" is the caller's intent, so a vanished row shouldn't fail the
batch. Not yet wired to a route, but available for the frontend if
it wants to coalesce multiple bell clicks.

**markAllRead covers broadcasts.** Per-user notifications get the
existing `readAt = now` sweep; broadcasts get a `markManyRead` over
every currently-unread broadcast id for that user (idempotent via
the `(userId, broadcastId)` unique index). Total transitions
returned to the client.

**Bootstrap wiring.** The `BroadcastRepository` is constructed once
and shared between `NotificationService` (for the feed merge) and
`BroadcastService` (for admin CRUD). The notifications block had to
move slightly so the repo exists at NotificationService-build time.

Service-test covers the new merge logic via in-memory fakes for
both repositories: interleave order, unread filter, broadcast id
routing through markRead, mixed-id markManyRead, mark-all-read
across both sources, idempotence on second call, and the
broadcasts-repo-absent fallback path for legacy callers that
construct NotificationService without it.

* chore(api): broadcastItems is const in feed merge (#500)

Drop a stray `let` in `listFeedForUser` — the array is pushed
into but never reassigned, so `const` is correct and eslint's
`prefer-const` catches it. No behaviour change.

* fix(web): align broadcast client types with confirmed API shapes (#500)

Backend signed off with two divergences from what was assumed in
this stack's earlier commits:

1. AdminBroadcastResponse uses `id` (the public DTO field), NOT
   `_id`. The notification-feed item still uses `_id` because that
   DTO predates broadcasts. They're deliberately different shapes
   even though they project off the same Mongo `_id` server-side.
2. Broadcast items in the user-facing /notifications feed do NOT
   carry a `category` field — only `_id, source, titleI18n,
   bodyMarkdownI18n, readAt, createdAt`. `category` is now
   optional on the shared `Notification` type so user items keep
   their typed enum while broadcast items legitimately omit it.

Effects:

  - broadcastsApi.AdminBroadcast.id replaces `_id`, with a comment
    explaining why the two DTOs differ.
  - BroadcastEditDrawer + BroadcastsPage updated to read `.id`.
  - The synthetic "broadcast" category I had speculatively added is
    dropped. The bell already keys off `source` for its tag chip;
    NotificationsPage now picks the chip label via `source` first,
    falling back to the CATEGORY_LABEL lookup for user items.

No API contract changes here — purely client adjustments to match
what backend actually ships. Lint / typecheck / build / tests all
green.

* fix(api): align AdminBroadcastResponse to _id convention (#500)

Admin CRUD endpoint was returning `id` while the user-facing feed
(`/notifications`) returns `_id` for the same broadcast — and the web
client typed both shapes as `_id`. Result: every BroadcastsPage row had
`b._id === undefined`, breaking the React key, the edit drawer's id
plumbing, and the delete confirm dialog.

Rename `AdminBroadcastResponse.id` → `_id`, update `toAdminResponse`,
and adjust the service tests that asserted against the old name.

Also drop the unused `UserBroadcastFeedItem` type — the merged feed
item is already defined inside `notifications/types.ts`'s `FeedItem`
discriminated union, so this duplicate was dead.

* revert(api): keep AdminBroadcastResponse.id — matches announcements (#500)

Earlier fix f253548 renamed `AdminBroadcastResponse.id` → `_id` to match
the merged-feed item's `_id` shape. That was the wrong direction:
admin/public response DTOs in this codebase use `id` (announcements
`PublicAnnouncement.id`, line 51 of announcements/types.ts) — the
convention is "REST DTO exposes `id`, Mongo doc keeps `_id` internal".
The merged-feed item uses `_id` because it directly extends
`NotificationDocument` (the Mongo shape), which is a different surface.

Frontend already aligned to `id` in ba62cfe before my fix landed, so
this restores the original backend contract and ends the back-and-forth.

Keeps the unrelated dead-code removal from f253548 (`UserBroadcastFeedItem`
type that was never imported anywhere — feed broadcast items live in
notifications/types.ts FeedItem union).

* chore(api): drop unused admin broadcast response Zod schemas (#500)

`adminBroadcastResponseSchema` + `adminBroadcastListResponseSchema`
were defined in `domains/broadcasts/schemas.ts` but no module ever
imported them — the route handlers return `c.json(...)` directly
without running the response through a runtime check, and the OpenAPI
builder doesn't pick them up either. Dead Zod schemas are a footgun:
any future drift between `AdminBroadcastResponse` (the TS type the
service returns) and the Zod shape goes unnoticed.

Delete rather than wire into OpenAPI — broadcasts aren't on the
public OpenAPI surface today and we can add the schemas back when
they actually have a consumer.

* docs: changeset for admin broadcast notifications (#500)

Both packages bump minor (new admin surface + new user-facing feed
source). Fixed-mode config means they version together.
* feat(web): broadcastsApi types — recipientUserIds on responses + create only (#502)

Wire the targeted-broadcast contract into the admin client:

- AdminBroadcast: add `recipientUserIds: string[] | null` (always present
  on the wire — null = broadcast to all users, non-empty array = targeted).
- CreateBroadcastInput: add optional `recipientUserIds?: string[]`. Omit /
  undefined → broadcast. Non-empty array → targeted. Empty array would
  be rejected with a 400 by the API.
- UpdateBroadcastInput: deliberately narrowed to title + body only.
  Recipients are immutable after create — the API rejects PATCH bodies
  that include `recipientUserIds`, so we keep the field off the type to
  make that impossible at compile time.

The `useBroadcasts` hooks stay shape-identical; the new field passes
through the existing wire layer untouched.

* feat(web): UserEmailPicker — debounced email search + chip multi-select (#502)

Reusable recipient-picker for targeted broadcasts and any future admin
surface that needs to pin an action to a hand-picked set of users:

- Search field with 300 ms debounce — feeds
  `GET /api/v1/admin/users?role=normal&q=…&pageSize=10`. The endpoint
  already supports `q`, so we reuse it directly instead of fetching the
  full list and filtering client-side.
- Chip strip above the input shows currently-selected users. The label
  resolves to the user's email by piggy-backing on a single cached page
  of normal users (admin user counts are small) — this avoids threading
  a dedicated id→email lookup endpoint through the API for chip render.
  Falls back to the raw user_id when a chip isn't in cache.
- Click a row to add, "×" on a chip to remove. Dropdown stays open after
  add so the admin can pile-add a handful of recipients in one pass.
- Outside-click + disabled state handled.

Returns `string[]` of user_ids via `onChange` so the parent stays in
control of selection state.

* feat(api): broadcasts.recipientUserIds schema + migration (#502)

Add `recipientUserIds: string[] | null` to the broadcast doc + admin
response. `null` (the canonical default) means broadcast to every user
— matching #500 behaviour. A non-empty array means the message is only
visible to those NyxID user_ids; commits 2 and 3 enforce that on the
create + feed-filter paths respectively.

The field is **immutable after create**. The repository's
`UpdateBroadcastDocInput` and the service's `UpdateBroadcastParams`
deliberately omit `recipientUserIds`, so adding it on a PATCH call is
a compile error — the cheapest guard against a future caller
forgetting the invariant. PATCH-time enforcement at the route layer
follows in commit 2.

Migration `backfillBroadcastRecipientUserIds` mirrors the announcements
bilingual backfill: a one-shot boot pass that writes
`recipientUserIds: null` on every pre-#502 doc where the field is
absent. Idempotent (matches \`\$exists: false\` only), non-fatal on
failure (the repo mapper's \`Array.isArray\` guard normalises absent
fields to \`null\` on the read path so the feed keeps working).
Wired into \`bootstrap.ts\` right after the broadcast repo indexes
are ensured.

Repository \`create\` accepts \`recipientUserIds\` in the input,
persists an explicit \`null\` on omitted / empty, and \`[...arr]\` on
a populated list. Service \`create\` propagates the field through and
logs the recipient count only — never the user_id list (PII-adjacent
and pointless for ops debugging).

Tests:
- New migration test covers the three scenarios: absent → null,
  already-set (null or array) → untouched, idempotent re-run.
- Existing service fakes updated to seed \`recipientUserIds: null\` so
  the new shape compiles.

* feat(api): admin /broadcasts CRUD accepts recipientUserIds on create only (#502)

Zod-level enforcement for the immutable-after-create rule on
`recipientUserIds`:

- `createBroadcastSchema` adds an optional
  `recipientUserIds: z.array(z.string().min(1)).min(1).optional()`.
  Omitting the key means broadcast-to-all (the #500 default). A
  non-empty array means targeted. \`null\`, \`[]\`, and arrays
  containing empty strings are rejected upstream of the service so
  bad shapes never reach the repo.
- \`patchBroadcastSchema\` is already \`.strict()\`. Any PATCH body
  carrying \`recipientUserIds\` now returns a 400
  \`INVALID_BROADCAST_INPUT\` via the route's existing safe-parse
  path with an \`unrecognized_keys\` issue — recipients are frozen
  at create time and cannot be edited.

Route handler threads \`parsed.data.recipientUserIds\` through to
\`broadcastService.create\` (the service + repo already accept it as
of commit 1). PATCH handler is unchanged — the schema does the
rejection.

Logging: \`broadcast created\` now carries \`recipientCount\` (=
\`"all"\` when null, the array length otherwise) — surfaced from
commit 1's service.create log line. We never log the user_id list
itself.

Tests (schemas.test.ts + extensions to service.test.ts):
- POST: omitted, present-non-empty, empty array, null, array with
  empty string, array with non-string entry — six paths covered.
- PATCH: rejected when \`recipientUserIds\` is present (asserts the
  Zod issue code is \`unrecognized_keys\` so a future schema change
  that silently accepts the field trips this test); normal patches
  still parse.
- Service round-trip: create with recipients shows up in
  \`listAdmin\`; \`update\` (no recipients in input by design — the
  type doesn't expose the field) leaves the persisted array
  unchanged.

* feat(web): BroadcastEditDrawer adds Recipients section (create-only) (#502)

Recipients are immutable after create — the API rejects PATCH bodies
that try to change them. The drawer reflects that contract directly:

- Create mode: a Recipients section appears above the bilingual title
  / body fields with an "All users" / "Specific users" radio pair.
  Selecting "Specific users" reveals the UserEmailPicker. Submit is
  blocked with an inline error if "Specific users" is chosen but no
  recipients have been picked. POST body only includes
  `recipientUserIds` when the targeted path is selected — for the
  default broadcast-to-all path the key is omitted entirely so the API
  treats it as a broadcast.
- Edit mode: Recipients renders as a read-only summary card —
  "All users" or "{n} users" with the resolved email chips. A locked-
  hint line ("Recipients are locked after creation.") makes the
  contract visible. Emails resolve via a cached page of normal users
  (same trick UserEmailPicker uses) so a freshly-opened drawer doesn't
  need a separate id→email lookup endpoint.
- The PATCH submission path defensively strips `recipientUserIds`
  before sending so an internal bug can't accidentally smuggle a
  field through that the API would already reject.

Test coverage extended: blocks submit when "Specific users" + empty
selection, plus existing happy-path + validation tests pass through
the new QueryClientProvider wrapper.

* feat(web): BroadcastsPage Recipients column (#502)

Add a Recipients column between Preview and Created so an admin can
tell broadcast-to-all from targeted at a glance:

- "All users" badge for the broadcast case (recipientUserIds == null).
- "{n} users" badge for the targeted case, with a hover-tooltip that
  resolves user_ids back to emails (one newline-separated line per
  recipient). Emails come from the same cached page of normal users
  the drawer uses — only fetched when at least one row in the table
  is actually targeted, so the broadcast-only case doesn't pay for
  the lookup.

Tooltip is the native `title` attribute rather than a custom popover
to keep the column lightweight; the chip + small recipient lists
(targeted broadcasts are inherently scoped) make that the right
trade.

* feat(api): merged feed filters broadcasts by recipientUserIds (#502)

Targeted broadcasts (#502) are invisible to non-recipients on every
read + write surface the notifications service exposes — not just the
merged feed. Treat them like rows that don't exist for outsiders:

- \`listFeedForUser\` filters the broadcast roster through
  \`isBroadcastVisibleTo(broadcast, userId)\` before joining read
  receipts. Everyone-broadcasts (\`recipientUserIds === null\`) stay
  visible to all; targeted broadcasts only render for ids in the
  recipient list.
- \`countUnread\` builds its unread count from the same visible roster,
  not \`unreadBroadcastIdsForUser\` (which is recipient-blind). A
  non-recipient sees no badge bump from a targeted broadcast.
- \`markAllRead\` only writes receipts for visible broadcasts —
  a non-recipient pressing "mark all" never inadvertently writes a
  receipt for a targeted broadcast they can't see.
- \`markRead\` (single id) now 404s for non-recipients on a targeted
  broadcast, matching the rest of the surface. This also closes a
  side channel: without the check, a non-recipient could probe whether
  a targeted broadcast id exists by comparing the error code on
  markRead vs markRead-of-typo.
- \`markManyRead\` filters broadcast ids through the same visibility
  predicate before batching — typos and unauthorised ids collapse into
  the same silent-skip path.

The visibility predicate \`isBroadcastVisibleTo\` is a single private
function at the top of the file so all five call sites share one
implementation. Adding a new visibility rule later (per-org
broadcasts, role-gated broadcasts) is one edit.

No new repo methods — the existing \`listAll\` + \`hasUserReadBroadcastsMap\`
+ \`markManyRead\` are enough. Cost: each surface that previously
called \`unreadBroadcastIdsForUser\` now does \`listAll\` instead, which
is the same two-query plan (broadcasts + receipts) but returns the
recipient field we need to filter. Broadcast count is bounded (<< 1k)
so the array filter cost is negligible.

Tests (notifications/service.test.ts):
- Targeted broadcast visible to listed recipients; invisible to
  others.
- Everyone-broadcast still visible to all.
- Targeted + everyone interleave correctly per-recipient.
- countUnread + markAllRead respect the filter.
- markRead 404s for non-recipients on a targeted broadcast.
- markManyRead silently skips non-visible broadcast ids.
- unreadOnly + recipient filter compose.

* feat(web): NotificationDetailModal + bell/page wiring (#502)

A broadcast can carry markdown bodies that don't fit in the bell-row
preview or a one-line list cell, so a click should open the full
content in place rather than trying to render every body inline.

- New `NotificationDetailModal` renders the bilingual broadcast in
  full: locale-resolved title as the heading, locale-resolved markdown
  body through `react-markdown` + `remark-gfm` + `rehype-sanitize` —
  the same safe stack `AnnouncementPopup` uses. Motion vocabulary
  matches Modal / ConfirmDialog (backdrop fade + dialog spring).
- Close vectors: ESC, backdrop click, explicit ×.
- On open: if `readAt == null` the modal fires the mark-read mutation
  passed in by the parent. Guarded on `id + readAt` so a parent
  rerender doesn't double-fire. The mutation lives in the parent so
  the modal stays stateless wrt React Query.
- NotificationBell + NotificationsPage now route broadcast-source
  clicks into the modal instead of "mark read + collapse" / "mark read
  + stay on page". User-source clicks keep the existing navigate
  behavior (link → that link, no link → /notifications).

* feat(web): i18n strings for recipients + modal (#502)

Bilingual strings to support the targeted-broadcast surface and the
broadcast detail modal:

- `adminPages.broadcasts.recipients.*` — section heading, audience
  radios ("All users" / "Specific users"), summary labels, locked
  hint for edit mode, picker placeholder + aria + states, chip
  remove aria.
- `adminPages.broadcasts.table.recipients` — new column header.
- `adminPages.broadcasts.errors.recipientsRequired` — submit
  validation for "Specific users" + empty selection.
- `notifications.broadcast.modal.aria` — accessible label for the
  detail dialog.

EN + ZH key trees match.

* chore(web): memoize BroadcastsPage items list to satisfy react-hooks lint (#502)

The `useMemo` for `hasTargetedRows` depends on `items`, but `items` was
recomputed from `broadcastsQuery.data ?? []` on every render — a new
array reference each time, which made react-hooks/exhaustive-deps flag
the dependency chain as unstable. Wrap the fallback in its own
`useMemo` so downstream memoization is actually stable.

No behavior change; lint-only.

* docs: changeset for targeted broadcasts + modal viewer (#502)

Both packages bump minor — backend gets a new schema field
(`recipientUserIds`) and feed filter; web gets the targeted-broadcasts
UI (UserEmailPicker, drawer Recipients section, page column) plus the
new NotificationDetailModal for the bell + `/notifications` page.

#500's changeset stays untouched — it describes a different
release-notes bullet ("admin can author bilingual broadcasts"), and
both changesets will be consumed together at the next release-bump
PR. Keeping them separate so the eventual release notes have one
bullet per behaviour change.
…del picker (#503) (#508)

* fix(web): translate Playground hero, starters, drawer chrome (#503)

The Playground page (`/playground?skill=…`) calls `t()` with `playground.*`
keys for its empty-state hero, the three prompt-starter cards, drawer tab
labels, kbHint, env-var lock hint, About / Tags / Skill page rows, and the
pin / unpin / pinned drawer header buttons — none of which existed in
either locale file. `react-i18next` was therefore returning the inline
English default at every render, so the entire surface stayed in English
under `lng = zh`.

Add every referenced key to `en.json` (verbatim copy of the existing
inline default) and `zh.json` (Chinese translation). No component change —
the components already point at these keys; the resolver just had nothing
to look up.

Refs #503

* fix(web): translate Generative skill builder hero, starters, drawer (#503)

CreateSkillGenerativePage calls `t()` with `generative.*` keys for its
eyebrow, hero title + subtitle, drawer hint, the three prompt-starter
cards (Slack notifier / Fetch GitHub PRs / CSV → JSON), the composer
askPlaceholder (distinct from the in-flight `placeholder` "Generating…"),
the Package drawer tab, pin/unpin/pinned, validation panel title, and the
empty-preview hero + hint. None existed in the locale files.

Add every referenced key to `en.json` (verbatim copy of the inline
default) and `zh.json` (Chinese translation). Bring `startOver` /
`saveSkill` casing into line with what the component renders (the page
uses sentence case; the locale had title case from an earlier draft).

Refs #503

* fix(web): route QuotaInline + QuotaChip through useTranslation (#503)

Both components used hardcoded English copy for everything visible — the
admin-bypass "Admin · Unlimited playground/skill-gen" stamp on the
playground / skill-gen surfaces (`QuotaInline`) and the paired
"Playground · ∞" / "Skill-gen · 190/200" pills in the breadcrumb-row
right rail on every authenticated page (`QuotaChip`). Switching to
中文 left them untouched.

Wire both through `useTranslation` under a new `quota.*` namespace:
surface labels, the admin/remaining chip variants (with their
aria-label + title attributes), the warning banner heading + body, the
admin-grant suffix, and the compact "calls left" / "+N grant"
microcopy. Keep the existing `aria.adminQuotaUnlimited` /
`aria.adminUnlimitedUsageTitle` keys on the QuotaInline admin stamp
since they're the established a11y keys.

The interpolation tokens use `{{value}}` (not `{{count}}`) — i18next
reserves `count` for plural-form numbers and TypeScript rejects
strings there.

Refs #503

* fix(web): route ModelPicker through useTranslation (#503)

The picker rendered the "Model" trigger label, the " — default" suffix
on the selected option, the "default" badge inside the menu, the
"Loading models…" placeholder, and the "<surface> — temporarily
unavailable. Contact admin." empty state as hardcoded English strings,
plus its trigger / listbox aria-labels were also hardcoded. All visible
under Chinese locale because none of these passed through `t()`.

Drop the inline `SURFACE_LABEL` map, plumb everything under a new
`modelPicker.*` namespace, and let callers still override the trigger
label via the `label` prop (the prop now defaults to `undefined` and
falls back to `t("modelPicker.label")`).

Refs #503

* docs: changeset for #503

* ci: re-trigger after transient npm registry failure
* fix(web): break NotificationDetailModal mark-read render loop (#509)

Clicking a broadcast item in NotificationBell / NotificationsPage crashed
the React tree with error #185 (Maximum update depth exceeded). The
mark-read useEffect inside the modal included `onMarkRead` in its
dependency array, but both call sites pass an inline arrow
`(id) => markRead.mutate(id)` — a new reference every render. The
mutation's `isPending` state shift rerenders the parent → fresh arrow →
effect deps shift → fires the mutation again → loop.

The existing `if (readAt) return` guard couldn't catch this: `readAt`
reads from the `notification` prop, which is a snapshot taken when the
user clicked the item, so it stays `null` while the live query cache is
updating in the background.

Two layered defenses, both modal-side so the fix doesn't require
callers to remember to `useCallback`:

1. Stash `onMarkRead` in a "latest ref" updated on every render. The
   mark-read effect invokes the ref instead of the prop, so the prop's
   identity is no longer part of the effect's deps.
2. Track the last id we've already marked in a separate ref. Even if
   the effect somehow reruns (e.g. a future regression to the deps
   array), the same id won't refire. Resets to null when the modal
   closes so reopening a different unread item still works.

Regression test rerenders the modal five times with a fresh
`onMarkRead` arrow each pass and asserts mark-read fired exactly once.
This is the exact production call shape that triggered #509 — the
existing tests mounted with stable props and didn't catch the loop.

Hotfix on top of #502. ornn-web only — no api changes; only the web
image needs a rebuild on deploy.

* docs: changeset for notification modal render-loop fix (#509)

Patch bump for both packages (fixed-mode config). User-visible impact:
clicking a broadcast no longer crashes the page.
* feat(web): hardcoded launch-celebration popup on landing page (#511)

Adds LaunchCelebrationPopup — a session-only modal that fires on every
mount of LandingPage for both anonymous and signed-in visitors,
announcing the public-launch free-credit promo (200 Playground + 200
Skill Generation credits for the first 500 GitHub stargazers, plus
the NyxID invite code for new users).

Deliberately independent of the dynamic announcements collection so
the launch notice cannot expire from the admin panel or depend on the
/announcements/active endpoint during launch traffic.

Dismissal sets local state only — no localStorage write. Navigating
away and back to '/' reopens the popup, by design for the launch
window. Remove the import + JSX line from LandingPage.tsx when the
offer ends; the component + tests can stay parked.

Visual language reuses the AnnouncementPopup Industrial Forge plate
(ember surface, Space Grotesk title, JetBrains Mono micro-label,
welded-seam divider, press-down CTA) sized one step larger (max-w-2xl)
to fit the launch body content.

Bilingual via i18n landing.launchPopup.* keys in en.json + zh.json.

Tests lock in the load-bearing contract: opens on mount, closes on
dismiss, reopens on remount (proves no persistence between visits).

* docs: changeset for #511
…ast (#513) (#514)

The launch popup shipped with an ember-orange surface (matching
AnnouncementPopup), but the inline GitHub repo link uses ember-deep —
a dark shade in the same orange family — so the most actionable
string in the popup was effectively invisible. Body paragraphs on the
orange surface also read as muddy because most strings sat at
text-obsidian /85–/90 opacity rather than full ink.

Flips the popup surface to obsidian (bg-panel) with a 2px ember
border. Title and strong body move to parchment; secondary body to
bone; meta to ash; the eyebrow micro-label and the GitHub link both
move to ember so they pop against the dark panel without colliding
with each other. Invite code shifts to molten gold for a single
high-value chip in the callout. CTA inverts to ember-on-obsidian.

Letterpress shadow, welded-seam rivet divider, press-down CTA
behavior, hard-offset shadow on hover — all unchanged. AnnouncementPopup
also keeps its ember plate; only this one-off launch-day modal flips.
…#515) (#516)

Full design pass. Replaces the previous obsidian-on-rust patch (which
relied on raw-var arbitrary classes that didn't theme cleanly + an
inline box-shadow that violates DESIGN.md) with a proper Forge
Workshop surface built from semantic theme-aware tokens.

New visual structure:
  - Bracketed mono eyebrow + ISO date for publication-cite signal
  - Space Grotesk display title anchored to text-strong
  - Welded-seam divider — hairline in strong-edge with ember rivets
  - Two-up offer tiles — 200 numerals in text-accent, mono brand
    labels, meta model captions, framed in elevated + subtle border
  - Numbered redemption steps (01 / 02) with tabular-num ember
    numerals; step 1 contains the GitHub repo link in ember with an
    accent-muted underline
  - Click-to-copy invite-code chip in molten-gold mono framed by
    accent-support/55; shows Copied ✓ for 1.8s. Silent no-op on
    older Safari / non-secure contexts
  - Limited-slots warning row with ember triangle prefix
  - Right-aligned CTAs: ghost Dismiss + primary STAR ON GITHUB,
    both wearing .cta-letterpress for press-down + reduced-motion

All surfaces use semantic tokens (bg-card, text-strong, border-accent,
border-subtle, border-strong-edge, bg-elevated, text-meta, text-accent,
text-accent-support, bg-accent, border-accent-muted, text-page) so
dark + light both resolve through the same code path. The card sits
on a hard-offset letterpress plate in bg-ember-deep — no inline
shadow strings anywhere.

i18n keys restructured for the new layout: per-tile {number, title,
caption}, split-step body strings (`step1Before`, `step1After`,
`step2`), invite-code copy/copied labels, limited-slots warning,
inviteAria. Old keys removed.

Existing tests still pass (opens on mount, closes on dismiss,
reopens on remount).
…ntext bug (#517) (#518)

Visual verification of #515 caught a load-bearing bug: the letterpress
shadow plate was painting OVER the card surface, not behind it.

Root cause was a stacking-context trap. The card div had
`position: relative + z-index: 10` — that combination establishes a
stacking context. Inside that context, a child with `z-index: -10`
paints between the parent's bg and its in-flow content, NOT behind
the parent. So the `bg-ember-deep` plate was sitting directly on top
of the card's `bg-card` bg, hiding it almost entirely (only a 6×6 px
L-corner of the actual card bg was exposed where the plate's
6px translate doesn't reach).

Dark mode masked the bug because both `bg-card` (#1A1610) and
`bg-ember-deep` (#7A2308) are dark warm tones — visually similar.
Light mode unmasks it: `bg-card` is `#FFFFFF` and `bg-ember-deep`
is `#6E2207`, a saturated rust red. The popup interior read as a
rust panel with low-contrast ink — exactly the regression #513 and
#515 were supposed to prevent.

Fix moves the plate from card-child to card-sibling inside a
positioning wrapper. No z-index hacks. Paint order = document
order — plate paints first (filling the wrapper bounds with the
6px translate offset), then the card paints over it with its opaque
`bg-card`. The plate is now visible only at the 6px offset edges,
which is the correct letterpress impression.

Animation moves up one level — the `motion.div` is now the wrapper,
so plate + card animate together. ARIA semantics move down one
level — `role="dialog"`, `aria-modal`, `aria-labelledby` live on the
inner card. The 3 existing tests still pass (they query by role,
which finds the inner card).

AnnouncementPopup has the same structural bug but doesn't read
visually because its card and plate are both ember-toned. Filed as a
separate follow-up rather than scope-creep this fix.
…vite code (#519) (#520)

The popup showed the invite code (`NYX-2XXJI08A`) under a small mono label
"NyxID invite · first-time users" with no further context. Users couldn't
tell where they'd be asked for the code, that Ornn's Sign In redirects to
NyxID (a separate product — our identity provider), or that they still
have to choose GitHub on the NyxID page after pasting the code.

Added a short body paragraph in the popup directly below the code chip
(and above the "Limited slots" warning) walking through the actual flow:

  click Sign In (top-right of Ornn)
    → redirected to NyxID
    → paste this code into the invite field
    → choose GitHub to finish sign-up

New i18n key landing.launchPopup.inviteHelp in both en.json and zh.json.
No tests changed — existing popup contract (mount, dismiss, remount)
still holds; the helper is pure presentational copy.
…t-redemption flow (#524) (#525)

Follow-up to #519. #519 explained where users would be asked for the
invite code (NyxID SSO login). This commit explains what happens *after*
they finish login + star the repo: delivery timing, where the code
arrives, how to apply it, and where to ask for help.

New body paragraph below the existing NyxID-flow paragraph and above
the "Limited slots" warning, walking through:

  - 24-hour delivery window (sets expectations, prevents anxious
    "did I do something wrong?" follow-ups)
  - Bell icon at top-right (names the actual UI element so users don't
    look in their email)
  - Profile dropdown → "Redeem code" (quotes the menu label)
  - Discussions thread link (https://github.com/.../discussions/521)
    for technical issues during redemption

Three new i18n keys (fulfillmentBefore / fulfillmentLinkLabel /
fulfillmentAfter) following the same split pattern as step1Before /
step1After so the discussions URL renders inline. Link is styled
identically to the GitHub link in step 01 (same accent / decoration /
hover tokens) for cross-section visual consistency.

No tests changed — popup mount/dismiss/remount contract is untouched;
the helper is pure presentational copy.
…els (#523)

* feat(web): add SkillIcon, EnvIcon, PackageIcon to shared icons module

Three new 1.5 px stroke icons in the existing `components/icons/`
voice (`fill="none"`, currentColor, IconProps): SkillIcon (document
+ content lines, SKILL.md metaphor), EnvIcon (key — env vars as
secrets), PackageIcon (cube — the canonical "shipping box" glyph).

Used by the rail tab handles on PlaygroundPage and
CreateSkillGenerativePage (next two commits).

* fix(web): playground rail tabs — icon + tooltip handles, fix CJK upside-down labels (#522)

The Skill / Env / Package rail tabs on the right edge of the
Playground rendered their labels with `writing-mode: vertical-rl`
plus `transform: rotate(180deg)`. That combo is a vertical-English
convention — `vertical-rl` lays Latin letters sideways, then the
180° rotation flips them so the word reads bottom-to-top. For CJK
characters, `vertical-rl` is already the *natural* upright vertical
writing mode; adding `rotate(180deg)` flips every character upside
down. End result: Chinese users saw a column of inverted glyphs and
reasonably concluded the page was broken.

Replaces the rotated text with icon-only tab handles plus a
horizontal mono-uppercase tooltip on `group-hover`. Wording matches
the drawer header voice: `[§ SKILL]` / `[§ ENV]` / `[§ PACKAGE]` —
the DESIGN.md bracketed mono operational labels. Tooltip is hidden
when the drawer for that tab is already open (the drawer header
provides the same context, so the tooltip would just be noise).

Tab handle fixes to `h-11 w-9` with a 16 px icon centred; all three
tabs are now equal height. Previously PACKAGE was ~1.7× the height
of ENV because of letter count, which broke the rail's vertical
rhythm. `aria-label` continues to use the i18n string so screen
readers stay locale-correct.

Preserves the warn-dot and pin-indicator affordances (warn dot
`top-1.5` adjusted for the slightly shorter handle).

Closes #522.

* fix(web): skill-gen rail tab — icon + tooltip handle, fix CJK upside-down label (#522)

Same fix as the Playground rail (previous commit), applied to the
single Package tab on CreateSkillGenerativePage. The page used the
same `writing-mode: vertical-rl` + `rotate(180deg)` pattern on the
"Package" label, which renders fine for English but upside-down for
Chinese.

Replaces the rotated label with a `PackageIcon` handle plus a
horizontal `[§ PACKAGE]` mono-uppercase tooltip that fades in on
`group-hover` when the drawer is not pinned open. Tab handle is
fixed to `h-11 w-9` to match the Playground rail's new geometry.
`aria-label` keeps using the i18n string.

Closes #522.

* docs: changeset for #522

* chore(web): localize playground rail tab aria-labels via i18n keys (#522)

After rebasing onto develop, the skill-gen page already used the
post-#508 i18n cleanup pattern (`t("aria.skillPackageDrawer")`) for
its rail tab aria-label. The playground's three rail tabs were still
using the older fallback-string concatenation (`${tab.label} drawer`),
which renders as English regardless of locale.

Adds two new keys (`aria.playgroundSkillDrawer`,
`aria.playgroundEnvDrawer`) to both `en.json` and `zh.json`; the
playground's package tab reuses the existing `aria.skillPackageDrawer`
key since the meaning is identical. Renames the railTabs object's
`label` field to `ariaLabel` to reflect its single remaining purpose
— the visible tooltip continues to use the hard-coded mono-uppercase
`tip` field (`SKILL` / `ENV` / `PACKAGE`) to match the drawer header
voice.

Closes #522.
…tency (#527)

* feat(web): LaunchCelebrationPopup — molten edge flow on offer tiles (#526)

The two "200 credits" tiles are the central benefit of the launch
popup but sat as flat matte rectangles, so the eye drifted past them.
Added a single bright ember-to-gold "comet" that travels around each
tile's perimeter over 5s, with a soft outer ember halo so each tile
radiates heat even at rest. The two tiles stagger by 50% of the cycle
(-2.5s on tile 2) so they read as independent objects, not CSS clones.

Pure CSS:

  - @Property --offer-angle (registered as <angle> so it interpolates
    smoothly during animation)
  - conic-gradient that uses --offer-angle as its `from` angle,
    rotated to 360deg via @Keyframes offer-tile-rotate
  - mask-composite: exclude (with -webkit-mask-composite: xor for
    Safari) to carve out the interior, leaving only the 1.5px ring
  - layered box-shadow halo: short bright molten-gold ring + longer
    diffuse ember glow

No JS, no Framer Motion, no per-frame React work. The conic-gradient
arc is asymmetric (~180deg lit, ~180deg transparent) so only one half
of the perimeter glows at any moment — keeps the effect premium-
feeling rather than gaudy.

Reduced-motion fallback: rotation suppressed, ring becomes a static
diagonal molten gradient; halo box-shadow stays in both modes (it's
not motion).

OfferTile gets an optional animationOffsetSeconds prop wired through
to the CSS via the --offer-tile-anim-delay custom property, so the
stagger doesn't require a second class or duplicated keyframes.

* fix(web): LaunchCelebrationPopup — align credit2Caption with credit1Caption (#526)

Both offer tiles spend the same conversation-credit pool, but the
captions diverged:

  credit1Caption (Playground)        Conversations · GPT-5.5
  credit2Caption (Skill Generation)  Drafts · GPT-5.5         ← stale

In ZH the same divergence: "对话额度" vs "创建额度". Two captions
for one credit type reads as "two different credit types", which is
misleading — users would assume Playground credits and Skill Generation
credits are non-fungible.

Aligned credit2Caption to credit1Caption in both locales.
… link (#532) (#533)

* feat(web): NotificationDetailModal renders user-source body (#532)

The modal previously assumed `source === "broadcast"` and rendered
locale-resolved bilingual markdown. Generalize it so user-source rows
(audit / quota) can use the same chrome: render `title` + plain-text
`body` wrapped in `whitespace-pre-wrap` so admin-supplied notes keep
their newlines and long redemption-code strings without HTML risk.
Tag chip resolves to a category label (Audit / Quota) instead of the
hardcoded broadcast tag. New `notifications.detail.modal.aria` i18n
key (en + zh) covers the screen-reader label for the non-broadcast
shape.

Test added for the user-source path; existing broadcast tests
unchanged.

* feat(web): open detail modal for non-broadcast rows missing a link (#532)

`handleOpen` / `handleItemClick` previously only opened the modal for
broadcasts. Non-broadcast rows fell through to `if (n.link) navigate`,
which meant quota credit notifications — emitted without a link by
design — were a dead click that only silently flipped `readAt`.

New routing for user-source rows:

  - has `link` → mark-read + navigate (unchanged; audit deep-links
    still go to the audit page)
  - no `link` + has `body` → open detail modal in place
  - neither → bell falls back to /notifications, page silently
    marks read (the existing terminal behavior)

Both surfaces (full /notifications page + navbar bell) get the same
treatment so the row's clickability matches user expectation
everywhere.

* docs: changeset for #532 (notification modal opens for non-broadcast rows)
…restrain palette (#548)

* refactor(web): unify Skill / File pane headers — equal h-9, drop redundant FILE prefix (#547)

Both `FileTree` and `SkillFileViewer` headers used the same paint
(`bg-elevated/40 border-b border-subtle px-4 py-2`) but their
content sized differently — the tree's `text-[10px]` label vs the
viewer's `text-sm` filename produced unequal line heights, so the
two pane headers misaligned across the seam in the unified package
panel.

Locks both to `flex h-9 items-center px-4` so the seam is straight
regardless of content. Also drops the `FILE` literal prefix from
the viewer header — the filename right next to it always implied
this; the prefix added no information and ate horizontal space on
narrow drawers. The filename is now the header.

* refactor(web): SkillPackagePreview — flat identity strip, drop multi-colour badge palette (#547)

The preview component had two structural problems compounding:

1. Multi-colour `Badge` palette — category got a cyan/magenta/yellow/
   green tint by `metadata.category`, and every tag got a hash-bucketed
   colour from the same four-colour pool. DESIGN.md is explicit that
   ember is the *only* action voice; spraying four accents across an
   identity card is exactly the "no decorative rainbow" anti-pattern.
2. Nested cards — an identity `card-impression` on top, then a panes
   `card-impression` below. Inside a drawer that *itself* is a
   `card-impression`, the user saw three rounded letterpressed
   surfaces stacked, which DESIGN.md flags as visual noise.

This commit:

- Drops the `Badge` import, `CATEGORY_BADGE_COLORS`, `TAG_COLORS`,
  and `getTagColor` together — none of them needed any more.
- Replaces the identity card with a single flat section, hairline-
  separated from the panes below. Category renders as mono
  `[§ CATEGORY]` in `text-accent` (same bracketed voice as drawer
  headers — `[§ PACKAGE]`, `[§ SKILL]`). Tags render as
  `tag · tag · tag` plain mono in `text-meta`. Author on the right
  of the title row in mono `text-meta` (`by NAME`).
- One outer `card-impression` wraps the whole component, header +
  panes, so the drawer sees a single rounded surface.
- `ResizablePanes` default split 30 % → 26 % gives the viewer ~4 %
  more horizontal room — meaningful on narrow drawers, the file
  tree rarely needs more than a quarter of the width.
- Drops the inline `style={{ minHeight: 320px }}` from the panes
  container — it was fighting `flex-1` and forced an overflow when
  the parent gave less than 320 px. The component now sizes purely
  off whatever flex height its parent allocates.

* fix(web): CreateSkillGenerativePage drawer — widen, pin actions, push past navbar (#547)

Three usability issues on the AI Skill Generation drawer:

1. **Drawer top clipped by navbar.** `top-4 bottom-4` slid the
   drawer under the 60 px navbar — the drawer header (`[§ PACKAGE]`
   + Pin + ×) was hidden behind the nav bar. Bumped to `top-[68px]`
   (navbar height + 8 px buffer); bottom unchanged.
2. **Whole drawer scrolled, hiding action buttons.** The body used
   `overflow-y-auto` over a `flex flex-col gap-4 p-4` wrapper, so
   on any non-trivial skill the user had to scroll past the file
   viewer to reach `Save skill`. Replaces with a proper flex
   column: validation errors at the top (`shrink-0`),
   `SkillPackagePreview` in the middle (`flex-1 min-h-0`, scrolls
   internally inside its panes), action buttons at the bottom
   (`shrink-0`, always visible). Drawer body no longer scrolls;
   the panes do.
3. **Drawer too narrow.** `w-[520px]` made frontmatter YAML wrap
   mid-key on Chinese descriptions and truncated the file tree's
   folder names to `hello-res…`. The preview *is* the artifact
   being built on this page (per the file's docstring), not an
   auxiliary peek — so it deserves real space. Bumped to
   `w-[min(960px,65vw)]` with the existing `max-w-[calc(100vw-3rem)]`
   safety cap.

Passes `className="min-h-0 flex-1"` to `SkillPackagePreview` so it
takes the body's middle row cleanly. `WeldedSeam` gets `shrink-0`
so the seam stays at its natural height when the preview fills.

* refactor(web): PlaygroundPage drawers — flat identity strip, mono status, match generative voice (#547)

Brings the Playground's three drawers (Skill / Env / Package) into
the same visual language as the rebuilt `SkillPackagePreview` and
the gen-page drawer, so the two surfaces feel like one product.

**Drawer chrome (all three drawers)**
- `top-4` → `top-[68px]` — same navbar clearance as the gen page.
- `w-[420px]` → `w-[min(560px,40vw)]`. Chat is still the page hero
  (`max-w-2xl`), so this is a moderate expansion — enough that the
  Skill description and Env labels stop wrapping awkwardly,
  without pushing the drawer over the chat composer.

**Skill drawer**
- Drops the `</>` icon-square left of the title, the outlined
  `v1.1` border pill, and the per-section `WeldedSeam` rivet
  dividers between About / Tags / footer.
- New flat identity strip: `name` in `font-display text-xl`,
  `v1.1` in mono `text-meta` on the right of the title row.
  `[§ category]` ember-bracketed mono + `tag · tag · tag`
  dot-separated mono on the next row. Description flows directly
  below, no preceded "About" label — the description is its own
  label.
- "Skill page →" registry link moves from a mid-content section
  to a hairline-bordered footer pinned at the bottom of the
  drawer. Always visible regardless of how long the description
  is.

**Env drawer**
- Drops `<Badge color="green">` / `<Badge color="cyan">` for the
  Set / Required indicator — same multi-colour palette critique
  as the gen-page redesign. Replaces with plain mono uppercase
  text: `text-success` when filled, `text-meta` when empty. One
  accent, used intentionally.
- Header becomes the same flat strip pattern as the skill drawer
  — `[§ env vars]` ember bracketed + description below — and the
  form itself scrolls in its own region under the strip.

**Package drawer** — picks up the `SkillPackagePreview` redesign
automatically (no direct edit). The `name@v1.1` + "Open in
registry →" header bar stays since `metadata={null}` is passed
through, meaning the preview itself doesn't render an identity
strip and the bar is the only identity hint.

**Cleanup** — drops the now-unused `Badge` import and the
local `WeldedSeam` helper (no remaining call sites in the file).

* docs: changeset for #547
…#550)

* chore(api): mask redemption code with **** instead of ellipsis (#549)

The grant audit + downstream quota notification carry a privacy mask
so the full redemption code never leaks. Mask glyph was U+2026 (`…`),
which works fine in the audit log but is ambiguous in the new
/notifications detail modal (#532): the ellipsis is what every UI on
the web uses to mean "text truncated, click to see more", so users
kept asking why the modal was hiding the rest of their code.

Swap the mask to `****`. First four chars of the code still leak (so
the redemption is still identifiable on a follow-up), nothing else
changes. Historical rows keep `…` — no migration needed; the row
context (QUOTA tag, "Redeemed code" prefix) already makes the
provenance unambiguous.

Lock the new mask in with a regression test that asserts the literal
note format on each grant audit row and explicitly rejects `…`.

* docs: changeset for #549 (redemption note mask glyph)
…en-page pulses on new iterations (#552)

* refactor(web): SkillPackagePreview — bump file-tree default split 26 % → 32 % (#551)

Typical skill folder names — `ornn-agent-manual-cli`, `cdn-image-
processor` etc. — are 18–24 chars. At the old 26 % default split,
the left pane is ~250 px on a 960 px drawer, which puts the name
right at the truncation boundary and ellipsises real skills to
`ornn-agent-man…`.

32 % gives ~300 px, enough horizontal room for those names without
truncating; the user can still drag the divider in either direction
to bias toward the viewer.

* refactor(web): playground drawer — absorb Skill tab into Package, unify width, fix overflow (#551)

After #548 landed, the Playground drawer rail had four small issues
that compounded into a "two products / two drawers" feel relative
to the Skill Generation page. This pass fixes them together because
they touch the same component and would each be a noop without the
others.

**1. Package drawer now surfaces full skill metadata.** Previously
`SkillPackagePreview` was called with `metadata={null}`, hiding its
identity strip; a bespoke `name@version | Open in registry` sub-
header bar covered for it. Now we synthesise a `SkillMetadata`-
shaped object from the registry skill record (via
`createDefaultSkillMetadata`) and pass it through, so the Playground
sees the exact same flat identity strip — name + bracketed
category + dot-separated mono tags + description — that the gen
page sees. The bespoke sub-header bar is gone.

**2. Skill rail tab dropped — Package drawer subsumes it.** With
the identity strip now rendered inside the Package drawer, the
Skill drawer was showing the *same* information one tab over. Two
tabs, one piece of info. `DrawerKey` narrows from
`"skill" | "package" | "env"` to `"package" | "env"`; the Skill
drawer body branch goes, the Skill rail tab definition goes, and
the now-unused `SkillIcon` import goes with it. The Env tab is
unchanged (still conditional on `needsEnvVars`).

**3. Drawer width unified.** `w-[min(560px,40vw)]` →
`w-[min(960px,65vw)]`, matching the gen page exactly. The earlier
narrower width was rationalised as "chat is the hero on the
Playground, drawer is auxiliary"; with the Package drawer now
hosting the same artifact view as the gen page, that rationale no
longer survives — the user's expectation is that the same content
fits the same way.

**4. Footer + flex-col wrapper fix.** The Package drawer body had
a padded `<div>` (non-flex) wrapping `<SkillPackagePreview
className="min-h-0 flex-1" />`. `flex-1` inside a non-flex parent
is a no-op, so the preview took its natural height and the file
viewer overflowed past the drawer footer. Wrapping in `flex
flex-col` makes `min-h-0 flex-1` claim the remaining height, so
the file viewer scrolls internally and the footer stays pinned.
The footer itself moves from inside-the-padding to an edge-to-
edge hairline-bordered row at the bottom (same pattern as the
Skill drawer's old footer), holding `skill.name@v{version}` on the
left and the registry link on the right.

The drawer chrome (`motion.aside` + header bar with `[§ NAME]` +
Pin / ×) is still duplicated between gen and playground by hand;
extracting it into a shared `<RightSlideDrawer>` component is the
next obvious refactor and intentionally out of scope here.

Closes #551.

* feat(web): skill-gen rail tab — pulse on new iteration while drawer is closed (#551)

The generative chat is a multi-turn refinement loop — the user
keeps typing "now make it bilingual", "add Chinese", "also tone it
formal" and each turn produces a fresh skill package. If the user
un-pins the drawer, the only signal that the artifact has changed
is the chat line `Generated: hello-in-chinese`, which scrolls away
as the user types the next prompt.

Detect the `generation.phase` transition `"generating"` →
`"preview"` while `drawerOpen === false`, set `hasUnseenIteration`.
Clear it whenever the drawer opens (hover or pin). While set, the
Package rail tab gets:

- Ember accent border + icon colour (instead of grey idle), so the
  tab itself reads as active-ish even when the drawer is closed.
- Two stacked rings around the tab: a steady `ring-accent/70`
  outline + an `animate-ping` ring at `ring-accent/40` that scales
  out to draw the eye.
- A small ember dot at the top-right corner with `animate-pulse`,
  for the case where the rings blend into the surrounding card.

All three layers are `pointer-events: none` so they don't intercept
the existing hover / click on the tab handle. The CSS animations
are Tailwind primitives (`animate-ping`, `animate-pulse`); no new
keyframes, no `shadow-[…]` arbitrary tokens (DESIGN.md restricts
those).

* docs: changeset for #551

* fix(web): move previewMetadata useMemo above early returns to satisfy rules-of-hooks (#551)

The previous commit placed `previewMetadata = useMemo(...)` after
the three early-return guards (`if (!skillName) ...`, `if
(skillLoading) ...`, `if (isOverLimit && ...) ...`). That violates
React's Rules of Hooks — hook call count must be the same across
every render of a given component, and an early `return` before a
hook can produce a render path with fewer hooks than the normal
path.

CI's eslint job caught it on `react-hooks/rules-of-hooks` (1 error,
PR #552 ci/lint check). The fix is purely structural — move the
`skillCategory` derivation and the `previewMetadata` `useMemo` up
to just after `activeDrawer`, before any guards. The hook now runs
unconditionally on every render. Behaviour is identical: when the
component would short-circuit (no skill name, loading, over-quota),
the memoised value is computed but the component returns before
it's ever read.
* feat(web): pin launch-celebration content to top of /news (#553)

Adds LaunchCelebrationNewsEntry — a hardcoded, non-modal sibling of
LaunchCelebrationPopup that stamps the public-launch free-credit promo
at the top of the News archive (/news), ahead of the dynamic
announcements feed.

A user who lands on /news from the navbar (i.e. anywhere other than /)
otherwise has no surface for the launch notice — the landing-page modal
only fires on '/' and the launch content is deliberately bundle-local
(cannot expire from admin, cannot depend on /announcements/active
uptime), so it never appears in the dynamic feed.

Reuses the popup's landing.launchPopup.* i18n keys verbatim — EN + ZH
inherited, no duplicated strings. Visual language matches the rest of
the News archive (card-impression article, bg-card, accent border)
rather than the popup's full-ember plate; click-to-copy NyxID invite
chip and "Star on GitHub" CTA are retained.

Cleanup contract: remove <LaunchCelebrationNewsEntry /> + its import
from NewsPage.tsx when the offer ends. Component file + i18n keys can
stay parked, same as the popup.

* docs: changeset for #553 (launch promo pinned to /news)
* docs: prep release v0.8.0

Adds the dated release-notes file (`release-notes-20260516.md`) so the
upcoming `develop → main` cut produces a tagged **v0.8.0** Release with
curated user-facing notes.

Two minor changesets pending on develop bump both packages 0.7.2 → 0.8.0
in fixed mode: admin broadcast notifications (#501) and targeted
broadcasts + click-to-popup viewer (#504). The remaining 15 patch
changesets cover the launch-celebration popup polish cycle, drawer
consolidation, i18n coverage on Playground / Skill builder / Quota chip /
Model picker, the redemption-mask glyph swap, and the notification-modal
mark-read / non-broadcast-open fixes.

Closes #555.

* chore: empty changeset for release-notes prep

Satisfies `check-changeset.yml` for this docs-only PR. No semver bump —
the version impact is fully covered by the existing pending changesets
on develop.
@chronoai-shining chronoai-shining merged commit 119829f into main May 14, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant